Build Your Own: Distributed Tracing System

Not an explainer — you already have that. This is a design session: you build the system piece by piece, commit to a decision before every reveal, and compare against a reference design afterward. The reveal is a design-review comment, not an answer key. Do the "build it yourself first" box before you touch the reveal — that's the entire point.

10 segments ~3–4 hrs, self-paced, bring paper Format: build → commit → reveal → compare Goal: design judgment, not recall
offset 00

Functional & non-functional requirements

build it yourself first

On paper: write your own 5–7 FR bullets, then NFR targets with real numbers — throughput (spans/sec), added latency to the caller, query latency, retention window, and your consistency/availability trade-off — for a tracing system sitting in front of MF order flow, IPO allotment, NSDL demat, MOFSL, PolicyBazaar, and LAMF. Then write one line on what you're deliberately scoping out, and why.

Q1Reveal a reference FR list — how close did yours land?reveal
FR1: A service can attach trace context to a request and have spans accepted without blocking the request.
FR2: Given a trace_id, return the full span tree within the retention window.
FR3: Every span carries structured status (OK / ERROR / TIMEOUT) plus a bounded set of key-value tags.
FR4: Sampling policy is configurable per service/tier, not one global rate.
FR5: Trace context survives async boundaries (queue/broker) via explicit propagation, not assumption.
FR6: Error traces and traces above a latency threshold are guaranteed to survive sampling, not left to chance.
FR7: Query access is exposed as an API to internal tools, not raw storage access. Why this shape: every FR here maps to a concrete downstream design decision later in this session (FR6 forces tail sampling in segment 2; FR5 forces explicit context-link handling in segment 8). An FR that doesn't constrain a later decision is usually filler.
Q2What should you explicitly scope out, and why does that line matter as much as the FR list?reveal
Scope out: 100% full-fidelity capture of every span (cost-prohibitive at this scale by construction — segment 5 will show why); cross-vendor trace federation; and rebuilding log aggregation inside the tracing system. Why it matters: an unscoped system quietly grows toward "let's also capture everything, forever, and search it like logs" — at which point you've rebuilt a second, worse logging platform on top of a data model that was never designed for full-text search. The scope line is what stops a tracing system from mission-creeping into something it structurally can't do well.
Q3Reveal reference NFR targets — did you put real numbers, or vague words like "fast" and "reliable"?reveal
Ingest throughput: 50k–100k spans/sec sustained, 3x burst. Added latency to the caller: <5ms p99 — must be fire-and-forget. Single-trace query latency: <500ms p95. Hot retention: 24–72h. Cold/compliance retention: 90 days minimum for regulated flows (allotment, redemption, disbursal). Consistency: eventual is fine for trace assembly (spans arrive out of order); durability of an accepted span is the real non-negotiable. Availability: the ingest path must degrade by dropping spans, never by slowing or blocking the caller. Why this framing: the single NFR that shapes everything else is the last one — tracing must never become the reason the real system is slow or down. Every later trade-off (tail sampling's memory cost, backpressure policy, agent design) gets judged against that one sentence.
offset 01

Scope & the core design decision

build it yourself first

Commit: head sampling or tail sampling. Write 2–3 sentences you'd defend in a design review, justified specifically against your FR6 (error/slow traces must survive) from segment 1 — not against sampling in the abstract.

Q4Which one guarantees you never lose an error trace to random chance — and what does that guarantee cost you architecturally?reveal
Tail sampling. The decision happens after you've seen the outcome, so error and slow traces can be kept deterministically instead of hoping a random head-sample coin flip caught them. The cost: the collector must now buffer every span of every in-flight trace until it's judged complete, which makes the collector stateful and memory-bound — a structural cost head sampling never pays, because head sampling decides at request-start and immediately forgets whatever it didn't select. This single choice is why segments 3, 6, 7, and 9 all end up dealing with buffering, memory pressure, and instance-affinity — it isn't a side detail, it's the decision's direct shadow.
Q5If FR6 is truly non-negotiable, is head sampling still viable for any part of this system?reveal
Only as a cost-control layer underneath tail sampling, never as the sole mechanism — e.g. coarsely head-filtering known-low-value background traffic before it ever reaches the tail-sampling buffer. Why not pure head sampling: a decision made before the outcome exists cannot, by construction, condition on that outcome. Pure head sampling and "guarantee errors survive" are logically incompatible — no amount of tuning the head-sample rate fixes that, it only changes the odds.
Q6What's the real price of choosing tail sampling, measured against your segment-1 NFRs specifically?reveal
Buffering must stay entirely off the path the caller waits on (or the <5ms NFR breaks immediately). Memory pressure per instance now scales with in-flight trace volume, not just span volume. And "when is a trace complete" becomes a real design problem — too eager and you cut off a slow trace before its signal arrives; too patient and buffers grow unbounded. Why say this out loud in a review: tail sampling is usually the right call for a system with an FR6-shaped requirement, but pretending it's free is how a design review gets ambushed later by a memory incident nobody budgeted for.
offset 02

Component breakdown

build it yourself first

Draw your own 5–7 box diagram, arrows and all. Before you reveal: write one sentence on why the hot-path decision logic (the sampler) can't live in the same component as the durable-write path (storage).

Q7Reveal the reference component diagram — how many boxes matched?reveal
Instrumented Service (SDK)
   | spans, async, non-blocking
   v
Local Agent / Sidecar          -- batches, local retry, backpressure
   |
   v
Ingestion Gateway               -- auth, validation, rate-limit (stateless)
   |
   v
Trace Assembler / Sampler       -- buffers per trace_id, tail decision (stateful)
   |                    \
   v                     v
Hot Storage           Cold / Archive Storage  <- async tiering (segment 7)
   |
   v
Query Service / API
   |
   v
Dashboards / Alerting / on-call tooling
Why this exact shape: every box exists because of a decision made earlier — the assembler exists because of segment 2's tail-sampling choice; the agent exists because of the <5ms caller-latency NFR; the split between hot and cold storage exists because of segment 1's retention numbers. A component diagram that isn't traceable back to a requirement is usually just habit.
Q8Why can't the trace assembler and the hot-storage writer live in the same component?reveal
They have opposite failure and scaling profiles. The assembler needs in-memory state (buffered spans, per-trace, waiting on a decision) and a latency-sensitive decision loop. The storage writer needs durability and scales cleanly by write volume once the decision is already made. What coupling them would actually break: a storage slowdown would backpressure the assembler's memory buffer directly, turning a durability problem into a memory-exhaustion problem. And a stuck decision loop (a sampler bug) would block writes for traces that had nothing to do with it. Separating them means each can degrade independently without dragging the other down.
Q9Why does the local agent/sidecar exist — why can't the SDK call the ingestion gateway directly?reveal
Because tracing must never become a source of latency or failure for the real request. The agent absorbs batching, local retry, and backpressure so the SDK's call is a local, fire-and-forget write — not a network call whose failure the business request has to survive. Why this isn't over-engineering: without the agent, a slow or unavailable ingestion gateway directly threatens every traced service's own availability — the exact violation of the segment-1 NFR that tracing exists to observe, not cause.
offset 03

Low-level API design

build it yourself first

Design your own 5–6 endpoints as a table: method, path, purpose, caller (be specific — a service? an operator? an autoscaler?), payload, idempotency behavior. Then decide: should the ingest endpoint and the policy-update endpoint share the same auth scope?

Q10Reveal the reference API surface — check your caller column especially.reveal
POST/v1/spans/ingest

Accept a batch of spans from an agent.

caller local agent / sidecar — never the SDK directly

payload array of {trace_id, span_id, parent_span_id, service, start, end, status, tags[]}

idempotency yes — dedup on span_id; re-ingesting an identical span is a no-op

GET/v1/traces/{trace_id}

Fetch the full assembled span tree for a trace.

caller dashboards, on-call tooling, an engineer's browser via the UI

payload none — path param only

idempotency n/a — read-only

POST/v1/sampling-policy

Update sampling rules (per-service rates, guaranteed-keep rules for errors/slow traces).

caller a human operator or a reviewed config-management pipeline — deliberately not an autoscaler

payload {policy_id, rules[]}

idempotency yes — policy_id makes reapplying safe

POST/v1/agent/heartbeat

Agent reports its buffer depth, drop count, last-flush time.

caller the local agent itself, on a timer

payload {agent_id, buffer_depth, dropped_count, last_flush_ts}

idempotency n/a — each heartbeat is a fresh observation, not a mutation

POST/v1/retention-policy

Set hot/cold retention windows, with per-flow overrides for regulated flows.

caller a platform or compliance operator, reviewed change

payload {flow_tag, hot_days, cold_days}

idempotency yes

DELETE/v1/traces/{trace_id}

Manual purge of a specific trace — e.g. a compliance/PII scrub request.

caller a compliance operator only — never a service

payload {reason_code} — required, not optional

idempotency yes — deleting an already-deleted trace returns a no-op success

Why the caller column matters more than it looks: three of these six endpoints are deliberately gated to a human/reviewed caller, not a service or autoscaler — sampling policy, retention policy, and trace purge. That's not caution for its own sake; it's the answer to Q11.
Q11Should the ingest endpoint and the sampling-policy endpoint share the same auth scope? Scope by ownership, or by blast radius?reveal
No — and this is a blast-radius question, not an ownership question. Ingest should be scoped narrowly to "can write spans for a given service identity" — high-volume, low-blast-radius-per-call, since a bad ingest call corrupts at most one trace. Sampling-policy and retention-policy need a small, audited operator scope, because one bad policy push can silently blind the entire tracing pipeline for every service at once. Why "blast radius" beats "ownership" as the scoping question: ownership-based thinking asks "who owns this API" and often lands both endpoints under the same "tracing team" scope. Blast-radius thinking asks "what's the worst outcome of a single misused credential" — and that answer is wildly different for these two endpoints even though the same team owns both.
offset 04

Data model & sizing

build it yourself first

Sketch your own span schema. Then estimate: at 80+ microservices, roughly how many spans/sec pre-sampling, how many persisted post-sampling, and how much hot storage per day? Do the arithmetic before revealing — don't guess a round number.

Pre-sample volume
~100k spans/sec
Effective keep rate
~8% head + tail-on-error/slow
Persisted volume
~8k spans/sec
Hot storage / day
~300 GB, pre-compression
Field classExamplesDurability needConsistency need
Durable configsampling policy, retention policy, service registrymust survive crashstrongly consistent across instances
Ephemeral derived statein-flight trace buffers in the assemblerfine to lose on crashlocal to one instance only
Persisted span datahot-storage span rows, cold-archive trace blobsdurable once writteneventual — spans can assemble out of order
Q12At 80+ services and ~20 spans/trace, is this actually a big-data problem — or does it just feel like one?reveal
Do the math before assuming yes. ~5k requests/sec platform-wide × ~20 spans/trace ≈ 100k spans/sec pre-sampling. After an effective ~8% keep rate (head sample + guaranteed error/slow retention), you're persisting roughly 8k spans/sec, each maybe 300–500 bytes — call it ~3–4 MB/sec sustained, ~250–350 GB/day before compression. Why this is not a big-data problem: that's a standard time-series / wide-column storage workload, well within what one reasonably-sized cluster handles — not a "we need a custom distributed database" problem. The mistake is sizing for 100% capture of unsampled volume instead of sizing for what actually gets persisted after sampling. Skipping this arithmetic is how teams over-provision storage by an order of magnitude before writing a line of code.
Q13Why is conflating "durable config" with "ephemeral derived state" a design mistake specifically for this system?reveal
Because they need opposite operational treatment. Policy data is small, low-write, and must be strongly consistent across every instance — a stale policy on one instance means inconsistent decisions for spans of the same trace_id that happen to land differently. In-flight trace buffers are the opposite: large, high-churn, and fine to lose per-instance on crash (worst case: a handful of traces skip tail sampling once). What conflating them costs you: store both the same way and you either over-pay for durability on data that doesn't need it (replicating megabytes of in-flight span buffers), or under-protect the one piece of state that genuinely can't afford to be lost (policy). The right instinct is to ask "what does losing this cost me" separately for every piece of state, not to pick one storage strategy for the whole system.
offset 05

Core algorithm — the tail-sampling decision engine

build it yourself first

Write actual pseudocode — not prose — for: (1) the buffer data structure holding in-flight trace spans, (2) the per-span-arrival logic, (3) the decision-trigger logic when a trace is judged complete. Then answer on paper: what happens when the buffer is full and a span for a brand-new trace arrives?

Q14Reveal the reference pseudocode — check your concurrency guard and your buffer-full path specifically.reveal
struct TraceBuffer:
  trace_id, spans: list<Span>, first_seen: ts, root_seen: bool, decided: bool

buffer_map: ConcurrentMap<trace_id, TraceBuffer>   # sharded by hash(trace_id)

on_span_arrival(span):
  buf = buffer_map.get_or_create(span.trace_id)     # atomic
  lock(buf):
    buf.spans.append(span)
    if span.parent_span_id is null: buf.root_seen = true

  if buf.root_seen and span.parent_span_id is null and span.end_ts is set:
    trigger_decision(buf)
  elif now() - buf.first_seen > MAX_TRACE_WINDOW:
    trigger_decision(buf)                            # forced, incomplete trace

trigger_decision(buf):
  lock(buf):
    if buf.decided: return                            # idempotent guard
    buf.decided = true
    keep = evaluate_policy(buf.spans)                 # error? p95+? priority? random%?
    if keep: persist_async(buf.spans)
    buffer_map.remove(buf.trace_id)                   # free memory either way

on_buffer_full(new_trace_id):
  evict_oldest_incomplete_trace()   # forces an early decision — chosen default
  # explicit alternative: reject_new_span(), incrementing a dropped_backpressure counter
  # never: drop silently with no counter at all
Why the guard and the memory-free step are both non-negotiable: without buf.decided, the root-arrival path and the timeout-sweep path can race on the same trace and double-persist or double-apply policy — corrupting the "one coherent decision per trace" guarantee tail sampling exists to provide. And freeing the buffer immediately at decision time, regardless of keep/drop, is what keeps the assembler's memory bound only to undecided traces — anything else is waste sitting in the exact resource this whole design is trying to protect.
Q15What's the actually-correct behavior when the buffer is full and a new trace's span arrives?reveal
There's no free answer here — you have to pick one and make it observable. Silently dropping without a counter is the worst option, because it turns a capacity problem into an invisible data-quality problem nobody can diagnose later. Why forcing an early decision beats outright rejection: a slightly-early, partial-trace decision degrades gracefully — if the error/slow tag has already arrived, that signal is still captured even on a forced decision. Rejecting new spans outright instead creates a hole specifically in the newest traces, which is usually the worse failure mode for an on-call engineer debugging something that just happened.
offset 06

Supporting subsystem — hot → cold storage tiering

build it yourself first

Design the tiering loop precisely: what triggers a trace moving from hot to cold storage, and what triggers a permanent purge? Then decide: one central coordinator running this for the whole platform, or every storage node running its own local loop?

Q16Reveal the reference tiering loop — did you make retention per-flow, or one global constant?reveal
every TIERING_INTERVAL (e.g. 15 min), per storage shard, independently:

  for trace in shard.traces_older_than(HOT_WINDOW):
    cold_days = retention_policy[trace.flow_tag].cold_days   # e.g. LAMF=90d, default=7d
    move_to_cold(trace)
    mark_purge_after(trace, cold_days)

  for trace in shard.traces_past_purge_date():
    hard_delete(trace)
    emit_audit_log(trace_id, reason="retention_expired")
Why per-flow, not global: retention is a compliance decision, not an infrastructure default — LAMF disbursal and IPO allotment carry real regulatory retention exposure that a blanket "delete after 7 days" would silently violate. A flow-tag lookup means the loop enforces what compliance actually decided, not what the loop's original author guessed was reasonable.
Q17Centralized single coordinator, or every storage node running its own local loop — which fits, and why?reveal
Distributed, per-node local loops. Tiering decisions are purely local — "does this shard have data past its own retention window" needs no cross-node coordination or global ordering at all. Why centralizing here would be a mistake: a central coordinator would just be a single point of failure and a scaling bottleneck for a job that's embarrassingly parallel by nature. Contrast this with sampling-policy distribution (segment 9), which genuinely does need centralized, consistent replication, because a stale policy split across instances actively corrupts decisions — tiering has no equivalent correctness risk from running independently per shard.
offset 07

Failure handling & the state machine

build it yourself first

Draw the state machine covering every state mentioned in segments 1–7. Mark which state is most at risk of "stuck forever" — and what specifically guarantees it always exits.

Q18Reveal the reference state machine — did you include a forced-exit guarantee on every state, or just the obvious one?reveal
RECEIVED_AT_AGENT -> BATCHED -> INGESTED_AT_GATEWAY -> BUFFERED
   -> DECIDED(keep) -> PERSISTED(hot) -> TIERED(cold) -> PURGED
   -> DECIDED(drop) -> [discarded, buffer freed]

Every state has a bounded max-time-in-state.
BUFFERED specifically: forced exit via MAX_TRACE_WINDOW (segment 6),
even if the root span never closes.
Why every state needs this, not just BUFFERED: it's the obvious one to protect because it's stateful and memory-bound, but INGESTED_AT_GATEWAY and TIERED can stall too (a downstream queue backing up, a stuck tiering job) — a state machine is only as safe as its least-protected state, so the discipline has to be applied uniformly, not just where the failure mode is most visible.
Q19Which state is most at risk of "stuck forever," and what exactly guarantees it exits?reveal
BUFFERED. A trace whose root span is delayed, lost, or never sent — a common real failure, since the originating request itself may time out before its own span closes — would otherwise sit in the assembler's memory indefinitely, waiting for a root-close event that never arrives. Why MAX_TRACE_WINDOW is the only real answer: it's the one guarantee that doesn't depend on the failure resolving itself. Every state in a distributed system that can wait on an external event needs exactly this shape of guarantee — "exit after N seconds no matter what" — or a single stuck upstream becomes a slow, silent, unbounded memory leak that's invisible until it isn't.
Q20Where does idempotency actually plug into this state machine, concretely — not just "everywhere"?reveal
At every transition that can be retried after a failure without the caller knowing whether it already happened: span ingestion (agent retry on a network blip — dedup on span_id), the DECIDED transition (the buf.decided guard), and the hot-to-cold tier move (a retried partial move must not double-write or leave a trace in two places). Why "everywhere" is too vague to be useful: naming the specific transitions forces you to check each one has an actual guard, rather than asserting idempotency as a general property of the system. A transition without a concrete dedup key or guard is a transition that will corrupt state under retry — in a distributed pipeline that's the normal operating condition, not a rare edge case.
offset 08

Scaling this component itself

build it yourself first

How do multiple instances of your own assembler/sampler stay in sync without a central coordination bottleneck? Write one concrete metric — tied to a segment-1 NFR — that tells you it's time to scale out.

Routing key
hash (trace_id)
Scale-out signal
70% buffer utilization
Secondary signal
>0 backpressure evictions/sec
Q21Why must every span of the same trace_id reach the same assembler instance, and how do you guarantee that without a central lookup?reveal
Because the tail-sampling decision needs all of a trace's spans buffered in one place — if span A lands on instance 1 and span B on instance 2, neither instance alone has enough information to decide correctly. Why consistent hashing, not a lookup table: routing by hash(trace_id) mod N at the ingestion gateway guarantees this with zero coordination overhead per request. The real cost only shows up when N changes — a naive rehash on scale-out would instantly split in-flight traces across old and new instance assignments, so scale-out needs a controlled rebalance, not an instant reshuffle.
Q22What's the concrete "time to scale out" metric, and how does it trace back to a segment-1 NFR?reveal
Assembler buffer memory utilization sustained above ~70% for several minutes, or the backpressure-eviction rate (segment 6's on_buffer_full path) going above zero and staying there. Why this maps to the <5ms NFR specifically: once buffer pressure forces the assembler to do extra work per span — forced evictions, early decisions — that latency budget is the first thing to slip, well before the more obvious CPU or raw throughput metrics move. Scaling on CPU alone would catch the problem too late for a system whose actual constraint is memory-bound state, not compute.
Q23Does scaling out the assembler mean scaling the policy store the same way?reveal
No — and treating them the same is the mistake. Why they need different scaling models: the policy store is small, low-write, and needs strong consistency across every instance (segment 5) — it scales by replication. The assembler scales by partitioning trace_id space across more instances. Conflate the two and a routine policy update ends up needing the same careful, staged rollout as a full instance-count change, when it should be a fast, simple, consistently-replicated push.
offset 09

End-to-end assembly

build it yourself first

Draw the whole thing from memory — both the hot/data path (span → … → query) and the control/config path (policy push → … → assembler) in one diagram. Don't look back at earlier segments while you do it.

Q24Reveal the reference end-to-end diagram — count how many arrows you missed.reveal
HOT / DATA PATH
SDK -> Agent -> Ingestion Gateway -> Assembler(hash(trace_id) routed)
     -> [DECIDED keep] -> Hot Storage -> (tiering loop) -> Cold Storage -> Purge
     -> [DECIDED drop] -> discarded
Hot Storage -> Query Service -> Dashboards / on-call tooling

CONTROL / CONFIG PATH
Operator -> POST /sampling-policy  \
Operator -> POST /retention-policy  >-- Policy Store (replicated, strongly consistent)
                                    /
Policy Store -> pushed/pulled by -> every Assembler instance
Agent -> POST /agent/heartbeat -> Platform Monitoring -> scale-out signal (segment 9)
Why these two paths are drawn separately but must both appear: the hot path is judged by the <5ms and throughput NFRs; the control path is judged by consistency and blast-radius (segment 4/11's authorization reasoning). A design that only ever draws the hot path is a design that's never actually reasoned about how a bad policy push gets safely rolled out — which is usually where the real incidents happen.
No answer key for this one: trace your own design back against your segment-1 NFRs, one at a time. Specifically — does your design actually hold the <5ms caller-latency NFR when the assembler buffer is full and forcing early decisions, or did you only design for the happy path where buffers never fill? If you can't answer that in one breath, that's not a gap in this document — that's your next design review.